Per-pool interval watermarks, and cpu load repairs - #232
Open
TurkeyMan wants to merge 5 commits into
Open
Conversation
TurkeyMan
force-pushed
the
ow/mem-cpu-stats
branch
from
August 17, 2026 08:52
efe1b35 to
ccb02d4
Compare
The ring was stepped by (b >> shift) - (a >> shift), the span of the idle interval alone. But g_bucket tracks where the *previous* wake left off, and the busy stretch between that wake and `a` can cross a bucket boundary on its own. When it does, both ends of the new idle span land in the same later bucket, the step count comes out zero, and the idle is added to a bucket that closed a while ago -- pushing it past cpu_bucket_len: a=212500(bkt 3) b=250000(bkt 3) fi=0 g_bucket=3 -> cpu[3]=40892 a=262500(bkt 4) b=300000(bkt 4) fi=0 g_bucket=3 -> cpu[3]=78392 <-- cap is 65536 get_cpu_load then sums more idle than the window holds and total_time - idle_time underflows, so the percentage is nonsense. Today that is masked: the nanosecond stamps are truncated to size_t, which on a 32-bit target wraps every 4.3s, so the step count is usually garbage, gets clamped, and resets the whole ring instead. Roll to the bucket the idle span starts in first, filling everything crossed on the way with zero, because none of that time was idle. Then credit the span itself. Bucket numbers move to 64 bits so they never wrap; they are only shifted and compared, so a 32-bit target pays nothing for the width.
Nanosecond buckets put total_time at cpu_bucket_len * 15, about 1.0e9, so cpu_time * 100 overflowed 32 bits above roughly 4% load and wrapped -- a fully loaded system reported 1%, which is why that figure always looked implausibly calm: true load 4% -> 4% true load 50% -> 3% true load 5% -> 0% true load 100% -> 1% Widening the multiply would fix it but costs a libcall on rv32. Decimating to microseconds is better: a whole 16-bucket window is then under 1e6, the load calculation stays a 32-bit multiply and divide, and the resolution given up is 1us against a 65ms bucket. Bucket length becomes 0x1_0000, so the window is 1.049s rather than 1.074s.
Sampling `used` once a second sees the level at the sample instant and nothing
else, so a transient spike that nearly exhausted a pool and a floor creeping up
underneath it both pass unnoticed. That is precisely the pair of shapes that
precedes an out-of-memory death on a small target.
Every alloc and free now nudges its pool's low/high pair; a sampler reads the
pair and re-arms both to the latest level, so each interval reports the extremes
reached within it. One sampler per pool: a second reader steals the first's
interval. Note and sample race only against each other's precision, and a lost
update costs one sample of resolution, which does not justify a CAS loop on the
allocation path.
Each platform feeds the watermarks from the truest source it has cheaply:
Bouffalo exact. Per-pool `used` is already maintained beside the TLSF pools,
so the nudge is two compares, and TLSF sees every allocation
including the ones vendor C makes through the malloc overrides.
ESP32 total minus heap_caps_get_free_size, deliberately not a counter of
our own. WiFi and lwIP allocate without passing through urt and they
are exactly the pressure worth watching. Pool totals are cached, so
a chip with no PSRAM does not walk the region list for an empty pool.
elsewhere a running total kept in urt.mem.pressure alongside the watermarks,
because nothing else counts allocations on those platforms.
Drivers declare has_pool_usage; those that do not track their own pools ride the
fallback hook in urt.mem.alloc, which compiles to nothing on the ones that do.
get_cpu_load() averages all sixteen buckets, which flattens exactly the bursts worth seeing: one bucket saturated inside an otherwise quiet second reads as 14% once spread across the ring, when the busiest slice in it was 80%. Report the quietest and busiest completed bucket alongside the average, giving cpu the same low/high treatment memory now gets. Microsecond buckets keep this in 32-bit arithmetic too: the widest term is cpu_bucket_len * 100, well inside a uint.
The arithmetic here is fiddly enough that all three defects above sat in it undetected, so pin it down. count_system_load reads the clock itself, which leaves nothing to assert against; split the body out as account_idle taking both stamps, and drive that from the tests. Covers a duty cycle whose busy stretch crosses bucket boundaries (the case that over-filled buckets), the fully-busy and fully-idle ends, a run straddling the point where a 32-bit microsecond stamp would wrap, and a single saturated bucket that the average hides but the range reports.
TurkeyMan
force-pushed
the
ow/mem-cpu-stats
branch
from
August 17, 2026 10:49
ccb02d4 to
fcfa050
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Independent of #224 -- this branches from
masterand does not need the page pool or the reclaim registry.Groundwork for a
systemdevice in openwatt (open-watt/openwatt#532) that shows what memory and CPU are doing over time, so a small target's slide towards an out-of-memory death is visible before it arrives.Interval watermarks
Sampling
usedonce a second sees the level at that instant and nothing else. Both shapes that precede an OOM death go unseen: the transient spike that nearly exhausted a pool, and the floor creeping up underneath it.So every alloc and free nudges its pool's low/high pair, and
sample_pool_usage()reads the pair and re-arms both to the latest level. Each interval then reports the extremes reached within it. One sampler per pool, since reading consumes the interval. Note and sample race only against each other's precision, and a lost update costs one sample of resolution, which does not justify a CAS loop on the allocation path.State lives in a new
urt.mem.pressure, self-contained. Each platform feeds it from the truest source it has cheaply:usedis already maintained beside the TLSF pools, so the nudge is two compares, and TLSF sees every allocation including vendor C's through the malloc overridestotal - heap_caps_get_free_size(), deliberately not a counter of our own: WiFi and lwIP allocate without passing through urt and they are exactly the pressure worth watching. Pool totals are cached so a chip with no PSRAM never walks the region list for an empty poolurt.mem.pressurealongside the watermarks, because nothing else counts allocations on those platformsDrivers declare
has_pool_usage; those that do not track their own pools ride a fallback hook inurt.mem.alloc, which compiles to nothing on the ones that do.CPU load
Going to report CPU the same way turned up three defects in
count_system_load, each fixed in its own commit.1. The ring is not advanced past busy time. It stepped by
(b >> shift) - (a >> shift), the span of the idle interval alone, butg_buckettracks where the previous wake left off. The busy stretch in between can cross a boundary by itself, and when it does both ends of the new idle span sit in the same later bucket, the step count comes out zero, and the idle is credited to a bucket that closed a while ago:get_cpu_loadthen sums more idle than the window holds andtotal_time - idle_timeunderflows. Now the ring rolls to where the idle span begins first, filling everything crossed on the way with zero, because none of that time was idle.2. The stamps wrap. They were truncated to
size_t, so on a 32-bit target nanoseconds wrap every 4.3s and the step count is usually garbage. That is what masked defect 1: the count gets clamped and resets the whole ring instead. Bucket numbers are 64-bit now; they are only shifted and compared, so a 32-bit target pays nothing for the width.3. The percentage overflowed.
total_timewas about 1.0e9, socpu_time * 100left 32 bits above roughly 4% load:Widening the multiply would fix it but costs a libcall on rv32. Decimating to microsecond buckets is better, and is what the second commit does: a whole 16-bucket window is then under 1e6, the load calculation stays a 32-bit multiply and divide, and the resolution given up is 1us against a 65ms bucket.
With those out of the way,
get_cpu_load_range()adds the same low/high treatment memory gets. The average flattens exactly the bursts worth seeing: one saturated bucket inside an otherwise quiet second reads as 14% spread across the ring, when the busiest slice in it was 80%.Verified
count_system_loadreads the clock itself, so the body is split out asaccount_idletaking both stamps, which is what the tests drive.Espressif, and forBL808_M0,BL808andBL618.